Skip to content

feat: Genie Agent managed-memory agent panel + bootstrap runbook - #7

Open
zuckerberg-db wants to merge 154 commits into
mainfrom
genie-agent
Open

feat: Genie Agent managed-memory agent panel + bootstrap runbook#7
zuckerberg-db wants to merge 154 commits into
mainfrom
genie-agent

Conversation

@zuckerberg-db

@zuckerberg-db zuckerberg-db commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an optional Agent panel to the SSO-SPN org view: a slide-out chat assistant backed by a curated Genie space (via MCP) and durable managed memory (Unity Catalog memory store).

The agent is space-scoped by default. GENIE_MCP_MODE=space with a GENIE_SPACE_ID is what bootstrap deploys, because a guest user has no workspace entitlements and cannot query workspace-wide Genie at all. Workspace-wide remains available as an explicit opt-out (GENIE_MCP_MODE=one).

Unlike the VSCode/notebook editors (Go proxy), the agent embeds through a Vercel-native reverse proxy at /api/agent-proxy that mints a workspace bearer from the current user's mapped service principal — so guest users never hit the Databricks OAuth wall.

The deployable Databricks App is assembled from the vendor/app-templates submodule (agent-openai-agents-sdk + e2e-chatbot-app-next) plus a local overlay in agent/.

Architecture

sequenceDiagram
    participant User
    participant Browser as Browser (Agent panel iframe)
    participant NextJS as Next.js (/api/agent-proxy)
    participant DB as Database
    participant OIDC as Databricks OIDC
    participant App as Agent App (Databricks)
    participant Genie as Genie space MCP
    participant Memory as UC memory store

    User->>Browser: Open Agent panel
    Browser->>NextJS: GET /api/agent-proxy
    NextJS->>DB: Resolve session, org, SPN mapping
    NextJS->>OIDC: client_credentials token
    NextJS->>App: Proxy with Bearer + HTML rewrite
    Browser->>NextJS: POST /api/agent-proxy/api/chat (SSE)
    NextJS->>App: Forward chat stream
    App->>Genie: ask_genie
    App->>Memory: read/write long-term memory
    App-->>Browser: Streamed answer
Loading

See also: src/app/docs/solutions/agent/page.tsx and public/solutions/agent/architecture.mermaid.

What's new

Frontend (Vercel)

  • src/components/agent/agent-panel.tsx — slide-out panel (feature-flagged)
  • src/app/api/agent-proxy/[[...path]]/route.ts — SPN token minting, SSE proxy, HTML rewrite (<base>, light theme)
  • src/stores/agent-panel-store.ts — panel open/minimize state
  • Wired into src/app/sso-spn/[orgId]/layout.tsx

Agent app (Databricks)

  • agent/ overlay: agent_server/ (Genie tools, managed memory), chat UI patches, databricks.yml
  • scripts/assemble_agent.sh — merges submodule + overlay → agent-build/
  • agent/scripts/deploy_agent.sh, vendor_wheels.sh, setup_memory_store.py, start_app.py

Auth / guest fixes

  • src/lib/auth-dynamic.ts — Okta plugin only initializes when env vars are set (fixes Vercel build prerender crash)
  • Guest API contract documented in README (was marked "Coming Soon" but routes exist)

Docs / bootstrap

  • README Agent Panel section
  • BOOTSTRAP.md + scripts/bootstrap.sh — end-to-end fresh-workspace runbook (Phases 0–9)
  • Solution docs index + agent solution page

Enable the panel

Frontend (.env.local / Vercel):

NEXT_PUBLIC_AGENT_ENABLED=true
DATABRICKS_AGENT_APP_URL=https://your-agent-app.databricksapps.com

Runtime prerequisites (or /api/agent-proxy returns 400/401):

  • Active org has workspaceUrl set
  • User has an SPN mapping (userSpns) for that org
  • Guest SP has CAN_USE on the agent app (documented in BOOTSTRAP Phase 6b)

Agent app (bundle in agent/databricks.yml, not frontend env):

  • GENIE_MCP_MODE=one — workspace-wide Genie (opt-out; guests cannot use it)
  • DATABRICKS_MEMORY_STORE — UC memory store FQN (must be created + granted post-deploy)

Deploy path (happy path)

git submodule update --init
bash scripts/assemble_agent.sh

cd agent-build
uv run --python 3.12 python scripts/quickstart.py --profile <p> --lakebase-create-new <name>
bash scripts/vendor_wheels.sh

databricks bundle deploy --profile <p> -t dev
databricks bundle run agent_openai_agents_sdk --profile <p> -t dev

uv run --python 3.12 python scripts/setup_memory_store.py <app-sp-client-id> \
  --memory-store workspace.default.firefly_managed_memory --profile <p>

Or use bash scripts/deploy_agent.sh <profile> (deploy → run → memory store).

Full fresh-workspace walkthrough: BOOTSTRAP.md.

Known limitations

  • First app start can take several minutes — container runs uv sync, npm install, and a Next.js production build before serving. Expect transient 503s; judge health on app_status=RUNNING, not deployment status alone.
  • Python 3.11 runtime on Databricks Apps (cp311 wheels vendored; README documents the pin).
  • UC memory store is not auto-createdsetup_memory_store.py must run after deploy or memory silently no-ops.
  • Genie needs queryable data — grant the app SP USE CATALOG / USE SCHEMA / SELECT + warehouse CAN_USE on schemas with tables.
  • Preview deployments: do not set NEXT_PUBLIC_BETTER_AUTH_URL (causes CORS on hash-suffixed preview URLs). Omit SPN_AUTH_OKTA_* unless using Okta.

Reviewer guide

Suggested read order:

  1. src/app/docs/solutions/agent/page.tsx
  2. src/app/api/agent-proxy/[[...path]]/route.ts — token + proxy
  3. agent/agent_server/genie_tools.py — Genie MCP tools (path resolved by genie_mcp.py)
  4. agent/agent_server/utils_memory.py — UC memory store REST API
  5. scripts/assemble_agent.sh + agent/databricks.yml — packaging

Intentional design choice: editors use the Go proxy; the agent uses the Vercel-native proxy (no Cloud Run / Go proxy required for the panel).

Test plan

Verified on a fresh workspace via BOOTSTRAP.md Phases 0–9:

  • assemble_agent.sh + submodule init
  • Databricks app reaches app_status=RUNNING (vendor wheels + bundle deploy)
  • setup_memory_store.py creates UC memory store and grants app SP
  • Vercel preview deploy with guest-only env tier (no Okta vars; no NEXT_PUBLIC_BETTER_AUTH_URL)
  • Guest API chain (workspace → SPN → user) + browser login via OTT URL
  • Agent panel opens and streams answers via the curated Genie space over seeded UC data
  • Cross-session managed memory (save in session 1, recall in session 2)
  • Genie attribution link visible in panel

Since this PR was opened (114 commits)

The description above covers the original agent panel. Most of what is now on this branch
is a runbook plus the harness that found its defects, so this section records what
changed rather than rewriting the history above.

The runbook

BOOTSTRAP.md is a harness-agnostic runbook an agent works through top to bottom, with
scripts/bootstrap.sh as its executable twin and scripts/check-runbook-invariants.sh
holding 55 invariants that keep the two in step. Every invariant was verified by
reverting its fix and confirming the check fails — several were rewritten after passing
against code that was deliberately broken.

What the harness found

An E2E harness provisions a clean VM and a fresh Databricks trial, hands a headless agent
the runbook, and scores the result against a 60-probe matrix. Twelve trial runs surfaced
21 defects, and the most valuable category was not crashes but output that pointed at
the wrong cause
:

Defect Why it mattered
Phase 9 printed ok: no enabled IP allowlist A false all-clear for a check that never ran
Pricing-tier response read as an auth failure It is a determinate answer: no allowlist can exist on that tier
Missing required field: principal An empty $SP_CLIENT_ID, reported as a request-schema bug
No API found for 'PATCH /permissions/warehouses/' An empty $WAREHOUSE_ID, reported as a missing API route
quickstart's does not exist or is deleted Normal first-run state; a run followed its bundle deployment bind suggestion
Space-mode gate skipped in silence The app shipped on workspace-wide Genie; guest users cannot query it
Guest SP had no CAN_RUN on the Genie space The guest flow was broken while every other check was green

Phase 6/8 also read variables that exist only in the first shell, so a second pass in a
fresh terminal produced errors that each blamed something other than the empty variable.
firefly_restore_phase6_context and a cwd-independent state.env fix that class.

Phase ordering: the app is born in its final mode

The app used to deploy first and be corrected afterwards — Phase 4 shipped it workspace-wide,
and Phase 6c redeployed it onto the space once one existed. That left a window where the
deployed app pointed somewhere guests cannot query, and it made the redeploy race Phase 4's own
deployment (400 Cannot update app ... pending deployment).

Phase 3f now resolves the warehouse, seeds the data, and creates the space before Phase 4,
which deploys with genie_mcp_mode and genie_space_id already set. Phase 6c is grants-only
and redeploys nothing. DEPLOY-AS-DOCUMENTED asserts Phase 4 succeeds on the first attempt.

"Cannot do this yet" is no longer reported as "you said no"

Three messages told operators their Phase 0 answers had been discarded when they had not been:

  • Phase 3f creates the space before any service principal exists, so it cannot grant and passed
    no --grant-guest. A missing flag defaulted to no, so an operator who answered yes was
    told the guest SP would get no access — while Phase 6c went on to grant it. Deferral is
    now its own state (--defer-grants) and names where the grants land.
  • The grants-only call passed --seed no and the run reported seeding "declined at Phase 0" on
    a run that had just seeded 16 tables. --seed skip means not this call's concern.
  • Every message hardcoded "Phase 6c", so a reader in Phase 3f was pointed at a phase they had
    not reached. The caller now passes --phase.

firefly_restore_phase6_context had the same shape of bug: state.env and inputs.env are
different files, it read only the first, and announced "assuming space" about a mode Phase 3f
had already written down.

Invariants 44 and 45 cover these, and each clause has been verified by sabotage. Two clauses
were vacuous when first written — a grep over the whole file was satisfied by a dry-run banner
that merely mentions the flag — and were rescoped until breaking the real call actually failed
them.

Genie One → Genie Agent

The product name is now Genie Agent throughout (49 sites). Where "Genie One" was carrying
the meaning workspace-wide, the text says workspace-wide instead — both modes are the
Genie Agent, so renaming alone would have made one and space read identically. The
GENIE_ONE_URL attribution link is gone: its audience is guest users with no workspace
access, so it led somewhere they could not open.

Surviving this merge

BOOTSTRAP.md and scripts/ exist only on this branch, so every branch reference was a
time bomb. Both clone paths now resolve the branch and fall back to the default once it
carries the runbook, Phase 2 asserts the runbook actually arrived, and the README badge
asks for FIREFLY_BRANCH (default main) rather than pinning anything.

Reviewer notes

  • CI cannot verify any of this. Actions are disabled on the repository
    ({"enabled": false} on /actions/permissions), so the three workflows here have never
    run. make check runs the invariants and typecheck locally; enabling Actions needs an
    admin and is the real fix.
  • Known-good state: trials 11 and 12 were clean on both passes — 0 regressions, 0
    inconclusive, 0 runbook gaps, with the agent itself reporting RUNBOOK GAPS: None.
  • Not verified: the Phase 9 memory round-trip needs a real browser and is recorded as
    N/A, and ACL-ATTRIBUTION needs an enterprise-tier workspace, which a trial cannot be.

…rcel-native SPN proxy

- Vendor databricks/app-templates as a sparse submodule (agent-openai-agents-sdk,
  e2e-chatbot-app-next) with an agent/ overlay + assemble_agent.sh.
- Add a slide-out Agent panel (store + trigger) to the sso-spn org view.
- Embed the managed-memory Databricks App via a same-origin Vercel route
  (api/agent-proxy) that mints the current user's SPN token (guest/BYOD) and
  injects it as a bearer, streaming responses (chat SSE). No Go proxy / Cloud Run
  needed for the agent.
- Chat UI overlay patches: base:"./" + a mount-prefix fetch shim so assets and
  /api/ calls resolve under the proxy subpath.
- proxy: buffer request body with Content-Length (chunked/duplex POSTs were
  rejected by the Databricks Apps front door with "Proxy error:")
- proxy: inject <base href> + force next-themes light so the embedded chat UI
  resolves relative assets under the mount and matches Firefly's light UI
- proxy: serve the injected HTML no-store and drop conditional request headers
  so per-request rewrites always reach the browser (app ETag never changes)
- panel: point iframe at /api/agent-proxy (no trailing slash) to skip the 308
- build: exclude agent/, agent-build/, vendor/ from the Next TS build and add
  .vercelignore so the frontend build ignores the agent overlay + submodule
Add a dedicated /docs/solutions/agent page (with architecture sequence
diagram) and Solutions-nav entry for the managed-memory agent panel, and
document the agent panel + Genie One usage across README, CLAUDE, AGENTS,
and .env.example.

- New docs-site page src/app/docs/solutions/agent/page.tsx +
  public/solutions/agent/architecture.mermaid
- Proxy implementation detail lives under Backend Configuration, not the
  overview
- README: Agent Panel section, "How the agent uses Genie One", agent env vars
- CLAUDE/AGENTS: Vercel-native proxy + submodule/overlay notes
- .env.example: NEXT_PUBLIC_AGENT_ENABLED, DATABRICKS_AGENT_APP_URL
The parent repo gitignores agent-build/, and `databricks bundle deploy`
respects the enclosing repo's ignore rules, so sync found zero files
("no files to sync") and would ship an empty app. assemble_agent.sh now
runs a local-only `git init` on agent-build/ to scope the bundle to it.
Verified: `databricks bundle validate` now reports 0 "no files to sync"
warnings.
The Build & deploy section was happy-path only. Add the generic
operational detail so anyone cloning the fork can deploy without tribal
knowledge: bundle validate + the "0 files to sync" health check, the
post-run HTTP 503 poll (container builds uv/npm/vite before serving), the
Python 3.12 pin for the PyO3 cap, overlay-applied sanity checks, and why
assemble_agent.sh git-inits agent-build.
The agent panel's /api/agent-proxy returns 400/401 unless the user's
active org has a workspaceUrl and a userSpns mapping for the user's email.
Spell this out in the "Enable it" section so it isn't a silent failure.
- add prerequisite section for provisioning the agent's Databricks
  resources (MLflow experiment + Lakebase via quickstart, wheels volume)
- document the post-deploy Lakebase permission grant required for memory
- add the required `bundle run` app resource key and readiness check via
  `databricks apps get ... RUNNING`
- list the Agent-panel SSO->SPN + guest env vars for Vercel deployment
- flag still-unverified claims (empty wheels volume sufficiency, lakebase
  name rules) inline for validation

Flagged UNVERIFIED items are inferred, not yet proven by a negative deploy test.
Genie One is queried as the app's service principal, so it only returns UC
data that SP can access. Without grants, data questions return empty. Document
the required USE CATALOG/USE SCHEMA/SELECT (+ warehouse access), flagged pending
verification of the exact minimal grant on a clean workspace.
… ordering

Replace hardcodes with bundle variables/derivation and fix the deploy flow
based on end-to-end cleanroom proof on a fresh workspace:

- Parameterize wheels-volume + memory-store namespace as bundle vars defaulting
  to workspace.default (works on Default-Storage workspaces with no `main`);
  derive HOST/WORKSPACE_ID/GENIE_ONE_URL at runtime instead of storing them.
- Sync pyproject.toml (not uv.lock) and install build deps offline from
  pre-vendored cp311 wheels via UV_FIND_LINKS (scripts/vendor_wheels.sh),
  avoiding the dead .dev proxy, the lagging .cloud mirror, and the 10-min
  startup wall.
- Frontend Drizzle migrations need no manual Lakebase grant: the Postgres
  resource binding is CAN_CONNECT_AND_CREATE, which auto-provisions the app
  SP's Postgres role at deploy. Drop the pre-build grant step.
- Add scripts/setup_memory_store.py and wire it into scripts/deploy_agent.sh:
  the durable memory is a UC memory store (not Lakebase) that must be created
  and granted READ/WRITE to the app SP — otherwise memory silently no-ops.
- start_app.py builds the frontend before binding the port so failures are
  honest in the live app_status; deploy state goes green at container-start.
- GAP-19: okta plugin in auth-dynamic.ts was unconditionally constructed
  with process.env.SPN_AUTH_OKTA_CLIENT_ID!, crashing Next.js prerendering
  when the env var is absent. Now guarded: plugin is only registered when
  all three SPN_AUTH_OKTA_* vars are present.

- GAP-20: NEXT_PUBLIC_BETTER_AUTH_URL baked at build time causes CORS on
  Vercel preview deployments. Updated .env.example with an explicit warning
  (commented out by default); updated README to say do not set this var
  unless using a stable custom domain on all deployments.

- GAP-21: Guest login was documented as "Coming Soon." Added a Guest User
  Provisioning section to README with the correct 3-step API sequence
  (workspaces → spns → users), required fields, and accurate response shapes.

- GAP-22: Added a Vercel deployment step documenting how to disable SSO
  protection on preview deployments. Corrected CLI command to the real
  `vercel project protection disable <name> --sso` (v54+ syntax).
BOOTSTRAP.md covers all 22 documented gaps with corrected commands,
a full interactive-auth flow (browser OAuth for Databricks, Neon, Vercel,
GitHub — no manual key entry), and a proven vs. inferred gap reference table.

scripts/bootstrap.sh implements the runbook as an executable shell script
with --dry-run and --stop-after=N flags for iterative testing.
Fixes missing step for creating a Databricks service principal with M2M
OAuth credentials for the guest login flow. Phase 9 now reads client ID
and secret from keyring instead of using a placeholder.

Also adds DATABRICKS_ACCOUNT_ID to Phase 0 inputs.
…9 keyring reads

- Phase 8b split into Tier 1 (guest login, required) and Tier 2 (admin
  Databricks OAuth, placeholder-safe for guest-only verification)
- Documents why U2M vars don't crash the build (plain object vs okta() call)
- Phase 8d stores PREVIEW_URL and GUEST_API_SECRET in keyring
- Phase 9 loads all vars from keyring — no implicit shell carry-over
- NG-1: workspace list requires PATH arg (workspace list / --profile)
- NG-2: delete stale HOST/WORKSPACE_ID/GENIE_ONE_URL yml-edit instructions;
  replace with note that these are runtime-injected; add catalog/schema
  override guidance only when non-default
- NG-3: AGENT_APP_NAME default → firefly-openai-managed-mem-v2 (bundle hardcodes this)
- NG-4: remove stale re-assemble step from Phase 4 (wipes quickstart .env + wheels)
- NG-5/6: run bundle deploy/run from agent-build/; add resource key
  (agent_openai_agents_sdk) to bundle run command
- NG-7: pass --memory-store to setup_memory_store.py
- NG-8: UC permissions → PATCH /api/2.1/unity-catalog/permissions/catalog/{name}
  (POST is not a valid method)
- NG-9: warehouses list -o json returns bare array (not {"warehouses":[]}); use
  /api/2.0/permissions/warehouses (not preview/sql endpoint, which returns 405)
- NG-10: SP create output uses SCIM camelCase applicationId (not application_id)
- NG-11/14: replace account-API/curl/UI-fallback block with
  service-principal-secrets-proxy create (workspace-level, no account admin needed)
- NG-12: DB_URL already stored in keyring; ensure drizzle step reads it
- NG-13: add pnpm install before drizzle-kit push (node_modules absent by default)
Also fix unclosed code block fences in Phases 3a, 3d, 4, 5.
GitHub strips cursor:// links from README markdown, so the badge opened
the shields.io image instead of launching Cursor. Route through
cursor.com/redirect so the deeplink handoff works from github.com.
…adge

cursor.com/redirect does not exist (404). GitHub also strips cursor://
links, so use Cursor's documented HTTPS prompt handoff to open the
genie-agent bootstrap workflow.
Add an explicit STOP gate so agents must confirm every required input
before Phase 1 (including read-only probes). Move [ASK] items to a
checklist with [ASK — REQUIRED, BLOCKING] markers. Default clone
directory is now the current working directory instead of ~/Projects/firefly.
- Phase 6c: check UC tables only; defer seeding to end-of-runbook guidance
- Phase 6b: grant guest SP CAN_USE on warehouse and agent app
- End sections: next steps when no UC data; agent drafts and files GitHub issues
Centralize solution metadata, add /docs/solutions hub, and link Agent Panel
from architecture pages, cross-links, nav, homepage, and README so it is no
longer missing from the lakehouse-apps-proxy documentation grid.
neonctl projects create --output json returns {"project":{"id":...}}, but
Phase 7 read json['id'] and got an empty/wrong value, breaking downstream
DATABASE_URL setup. Read project.id with a flat id fallback for older
neonctl versions.

Fixes #16
Phase 8d captured the preview URL with `grep -E 'https://' | tail -1`, which
matched Vercel's trailing "To change the domain… https://vercel.com/…" help
line instead of the deployment URL. Phase 9 guest API calls then hit a
malformed PREVIEW_URL. Anchor the match to the .vercel.app host.

Fixes #15
…al edit

Phase 3b only warned the user to hand-edit agent-build/databricks.yml when
UC_CATALOG/UC_SCHEMA differed from the workspace/default bundle defaults;
skipping the edit broke the deploy or misrouted Genie context. The bundle
already exposes catalog/schema as variables (agent/databricks.yml header),
and DATABRICKS_MEMORY_STORE is the template
"${var.catalog}.${var.schema}.${var.memory_store_name}". Thread the Phase 0
inputs through `bundle deploy/run --var` so no file mutation is needed and
the memory store resolves automatically.

Fixes #12
Phase 1c called `vercel login` without checking the CLI was present, so a
fresh machine failed at the OAuth step. Add an install check (npm i -g vercel)
mirroring the neonctl/pnpm pattern, so no CLI is assumed pre-installed.

Fixes #8
vercel link (Phase 8) needs a repo the deployer can write to, but the clone
points at databrickslabs/firefly (no external write access). After cloning,
create or reuse a user-owned fork and push genie-agent to it, so Vercel's Git
integration links to that remote. Honors SKIP_GITHUB_PUSH and GITHUB_FORK.

Fixes #17
On machines behind a corporate MITM proxy (Zscaler etc.), quickstart.py and
the Databricks SDK fail TLS verification and hang until timeout. Phase 0 now
accepts a combined PEM (via prompt, or preset TLS_PEM_PATH for CI) and exports
REQUESTS_CA_BUNDLE / SSL_CERT_FILE / NODE_EXTRA_CA_CERTS for the session.

Fixes #13
… via npm (#9)

Reorders Phase 1 so pnpm is installed before the CLIs and frontend build
that depend on it, then Vercel, GitHub, Neon, Databricks.

- pnpm moved to 1a (was 1d)
- GitHub CLI: auto-install from the official release archive into
  $HOME/bin when absent (no Homebrew dependency), then auth. Deviates
  from the issue's `brew install gh` suggestion because the target
  audience is not guaranteed to have Homebrew.
- Neon CLI: `npm install -g neonctl` instead of `brew install neonctl`
  for the same reason.
- Install steps are DRY_RUN-guarded so --dry-run never hits the network.
…rting state

Three symptoms on one live run, all from Phase 8/9 trusting variables that only exist
in the first shell:

init_state_dir fell back to $PWD, so a phase invoked from anywhere but the repo read a
DIFFERENT state.env. Phase 9 concluded GUEST_API_SECRET was "0 chars" and spent about
three minutes reminting and redeploying a secret that was already stored correctly. The
location is now recovered from inputs.env, making it a property of the bootstrap rather
than of the caller's directory.

Phase 8d does store_secret PREVIEW_URL "$APP_ORIGIN". In a fresh shell APP_ORIGIN was
unset, so a good value was overwritten with "" and Phase 9's guest-login curls returned
HTTP 000 with nothing to explain it. store_secret now refuses to overwrite a stored
value with an empty one and says the variable was unset, and Phase 8d recovers
APP_ORIGIN before using it.

Phase 8e printed "Production does not serve " with no hostname, which reads as a domain
mismatch when the deployment was correct and aliased. It now names the empty variable
and says explicitly that this is not a domain mismatch.

Invariant 38 covers both root causes. Its first version failed against correct code
because it matched "$PWD" inside this function's own explanatory comment -- the second
time a check here has been decided by prose instead of by what executes, so it strips
comments before looking.
Phase 8d ends with store_secret GUEST_API_SECRET "$GUEST_API_SECRET", re-storing a value
minted back in Phase 8b. After a shell boundary that variable is empty, so the line
passes nothing. Nothing was lost -- the empty-write guard added in the previous commit
refused the overwrite and kept the stored value, which is how the run reported this as an
inconsistency rather than a failure -- but the call was a save that saved nothing, while
APP_ORIGIN two lines above already recovered itself first.

Three keys had that asymmetry: GUEST_SP_SECRET, GENIE_SPACE_ID and GUEST_API_SECRET. A
store immediately after a mint is fine and stays as it was; only a re-store needs the
recovery, and invariant 39 encodes exactly that distinction so the next one cannot slip
in unnoticed.
…ly script

The deployment summary is the one table an operator forwards to whoever will use the
app, and its second row read

    | Expired or already used? | `bash scripts/new-guest-link.sh --open` |

That reader cannot run it. The script needs a clone of this repo and reads PREVIEW_URL,
GUEST_API_SECRET, GUEST_SP_CLIENT_ID and GUEST_SP_SECRET from
.firefly-bootstrap/state.env, exiting and naming whichever is missing. So the row was
useless to the person reading it, and the only way to make it work would be to hand over
guest-SP credentials.

The app already handles this correctly -- the guest-login page says "ask whoever shared
it to send you a fresh link" -- so the summary was contradicting the product's own UX.
The row now says the same thing, and the command moved to a block addressed to the
operator that states its prerequisites and that those values are credentials to keep
local.

Invariant 40 fails if any row of that table asks its reader for a shell command or names
a secret.
…" where that is the point

The app's attribution component has returned 'Genie Agent' for a while; the docs lagged
it in 49 places across the runbook, README, docs pages, docstrings and runtime messages.

Three of those were not renames. Where "Genie One" was carrying the meaning
"workspace-wide", swapping the name alone would have left `one` and `space` reading
identically -- both modes ARE the Genie Agent, and the only distinction that matters is
workspace-wide versus a single curated space. Those say workspace-wide now, including the
fallback messages, where "staying on Genie Agent" would have described falling back to
the product itself.

Three references keep the old label deliberately, quoted: the passages explaining why a
link LABELLED "Genie One" was removed. Renaming a quoted historical label makes its own
explanation self-contradictory.

Not touched: the mode VALUE `one`. It is an identifier, and renaming it would break
bundle variables and GENIE_MCP_MODE for no reader's benefit. Also unchanged is the Genie
space title, which already read "Firefly Genie Agent" -- the harness matches our space by
exact title, so a rename there would have orphaned every existing space and created
duplicates on the next run.

Invariant 41 allows the quoted historical label and fails on any other use.

Also in this commit: Phase 6's context restore moved above the phase's FIRST consumer.
It sat ahead of the warehouse PATCH, leaving the catalog PATCH -- the first command in
the phase -- reading an empty $SP_CLIENT_ID, which the API reported as "UpdatePermissions
Missing required field: principal", i.e. apparently a request-schema bug. Invariant 36
now derives the consumers from the variables themselves rather than from a list of two I
picked, and joins line continuations, because $SP_CLIENT_ID appears on the `--json`
continuation of that call and a per-line filter skipped exactly the command that failed.
…k lives on

BOOTSTRAP.md and scripts/ exist only on genie-agent -- origin/main has README.md and
neither of them. That makes every branch reference a time bomb pointing in one of two
directions:

  * `git clone --branch genie-agent` is right today and fails the day the branch merges
    and is deleted, with "Remote branch genie-agent not found in upstream origin" -- at
    Phase 2, the first step a new reader takes.
  * Dropping the branch fails right now, because the default branch has no BOOTSTRAP.md.

Neither fixed choice survives the transition, so both clone paths resolve it: prefer the
bootstrap branch while `git ls-remote` still finds it, fall back to the default branch
once it has absorbed the runbook. Phase 2 then asserts BOOTSTRAP.md is actually in the
checkout, because otherwise the first symptom of cloning the wrong branch is a missing
file several phases later, which reads as a broken repo.

The README's "Open in Cursor" deeplink had the same pin and cannot run shell to resolve
it, so its prompt now tells the agent to check for genie-agent and use the default branch
if it is gone.

The issue templates hardcoded "Bootstrap branch: genie-agent" in the Environment block.
After the merge that sends a maintainer to code the reporter never ran, so they report
`git rev-parse --abbrev-ref HEAD` instead.

Invariant 42 fails on all four ways this regresses. Its first version had a dead arm:
piping a repo-wide grep into `grep -q` and setting the flag with `&&` can never fire,
because grep -q exits on first match, SIGPIPEs the upstream grep, and `set -o pipefail`
turns the pipeline non-zero. It passed against a deliberately re-pinned clone until the
pipeline was replaced with a captured variable.
…ing a branch

Two changes, both aimed at the same rot.

The README's "Open in Cursor" badge encoded the branch inside a cursor.com query string,
which no amount of markdown can make branch-relative. It now points at ./BOOTSTRAP.md --
a relative link, which GitHub resolves against whatever ref the reader is viewing. Correct
on genie-agent today, correct on the default branch the moment the merge lands, with no
branch named in README.md at all. The badge label changed with the target, since a badge
reading "Open in Cursor" while pointing at a markdown file is its own small lie. The
one-click-into-Cursor convenience is the price; permanent correctness is what it buys.

github-source-link.tsx derived its blob URL from a GITHUB_REPO constant and then built its
RAW url from a second, separately hardcoded copy of the same owner and repo. Changing the
constant would have moved one link and left the other pointing at the old repository --
invisible until a user clicked. Both now come from src/lib/repo-links.ts, and the produced
URLs are byte-identical to before; this is a refactor, not a behaviour change.

Deliberately NOT unified: which repo and branch each component targets. The docs "edit
this page" links and the source-view links are not one fact, and collapsing them into a
single constant would be a behaviour change wearing a refactor's clothes.

Still UNVERIFIED and left exactly as it was: github-source-link points at
stikkireddy/firefly-analytics-monorepo, not databrickslabs/firefly. That component renders
in top-nav, sso-spn-top-nav and the sso-spn-admin sidebar, so every page with that nav
sends users there to view source. Marked with a comment; deciding it is a product call.

Invariant 43 bans hand-built GitHub URL strings outside repo-links.ts. Invariant 42 also
had to stop trusting grep's --include/--exclude-dir: on the BSD grep macOS ships,
--exclude-dir=.git did not exclude and --include='*.md' matched .git/COMMIT_EDITMSG, a
file with no extension -- so the check failed on the very commit message describing the
fix. It filters paths explicitly now.
… pin

Dropping the deeplink outright traded a real convenience for correctness when both were
available. The branch only had to stop being load-bearing.

The prompt now says to clone the repo and work through BOOTSTRAP.md, and only then: "if
BOOTSTRAP.md is not present on the default branch, check out the genie-agent branch, which
has it." Today the default branch lacks the runbook, so the fallback fires. After the merge
the condition is simply false and the branch name goes inert -- rather than becoming a
clone that fails. That is the difference between naming a branch and depending on one.

Both badges now sit side by side: the Cursor deeplink for one-click setup, and the relative
./BOOTSTRAP.md link, which needs no branch at all because GitHub resolves it against
whatever ref the reader is on. Invariant 42 still fails on a deeplink that names the branch
with no fallback.
…tags error

The comment marked this UNVERIFIED on the grounds that the failure only reproduces on a
proxied network. A proxied run has now tested it: every vercel command still printed
"Error: Failed to fetch dist-tags from npm" first and then succeeded. Keeping the export
is still worthwhile for the outbound chatter it does cut, but the runbook should state the
tested result rather than leave a reader expecting silence they will not get -- the whole
reason that line matters is that it reads like an auth or install failure.
Branch selection was the one thing this runbook decided behind the reader's back. Phase 0
blocks on fifteen [ASK - REQUIRED, BLOCKING] items, so detection-with-fallback was the odd
mechanism out, and "preserve the one-click experience" was defending something that does
not exist -- the badge opens straight into a wall of Phase 0 questions regardless.

The prompt now asks for FIREFLY_BRANCH with a default of main, which is the value that is
correct once this branch merges. What makes that default safe before then is the second
half: the agent must confirm the branch it cloned actually contains BOOTSTRAP.md and ask
again if it does not. Accept the default today and you are told immediately, rather than
meeting a missing BOOTSTRAP.md at Phase 2 and reading it as a broken repository.

README.md no longer names a branch anywhere. Phase 2's firefly_resolve_branch and the
runbook's post-clone assertion stay as they are -- a reader who never touches the badge
still needs Phase 2 to work on its own.
…r the checks

github-source-link.tsx pointed at stikkireddy/firefly-analytics-monorepo. That repository
returns 404 from the GitHub API, from github.com and from raw.githubusercontent.com, and
databrickslabs/firefly is not a fork of it (fork=false, no parent) so it was not a rename
either -- the reference was simply dead. The component renders in top-nav, sso-spn-top-nav
and the sso-spn-admin sidebar, so every page carrying that nav offered users a "view
source" link that 404s. Repointed at the repository this code actually lives in; blob and
raw URLs confirmed 200 on both main and genie-agent.

Also adds `make check`, which runs the runbook invariants and the typecheck together.
Actions are disabled on this repository ({"enabled": false} on /actions/permissions), so 55
invariants and a 60-probe regression matrix are enforced by nothing automatic -- they hold
only when somebody runs them. Repo-local git hooks are not an option either: core.hooksPath
points globally at ~/.databricks/githooks, which is not ours to edit. So the least-bad
remedy is to make the gate trivial to invoke and say plainly in the Makefile why it matters.
Enabling Actions remains the real fix and needs a repo admin.
@zuckerberg-db zuckerberg-db changed the title feat: Genie One managed-memory agent panel feat: Genie Agent managed-memory agent panel + bootstrap runbook Jul 28, 2026
…s the space

genie_tools.py held its own `GENIE_MCP_PATH = "/api/2.0/mcp/genie"` and POSTed straight to
it, while agent.py resolved the mode properly and pointed the MCP server at
/api/2.0/mcp/genie/<space_id>. Both were attached to the same Agent, and GENIE_INSTRUCTIONS
tells the model it MUST call the local tool first -- so a deployment configured for a
curated space answered from workspace-wide Genie anyway, with the space-scoped server
connected and unused.

That is not a cosmetic split. Choosing a space is how answers get scoped to curated tables
and joins, and the space is the only object a guest can hold CAN_RUN on. Guests have no
workspace access, so answering them from workspace-wide Genie cannot work at all -- and
every configuration probe still reported mode=space, because the deploy really did pass
that variable. Nothing measured which endpoint a question reached, which is why twelve
clean trial runs are consistent with this bug.

genie_mcp.py now resolves it once; neither file holds a path constant. The default is
`space`, since the Genie Agent IS the space, and `one` becomes an explicit opt-out rather
than a fallback. Resolution RAISES when the mode wants a space and no id is set instead of
quietly degrading -- a silent fallback is what kept this invisible -- and logs the resolved
path once so the backend is observable rather than inferred.

`space` stays the canonical value on purpose: renaming it to `agent` at the same time as
the phase reordering would be two changes at once, each able to mask the other. `agent` is
accepted as an alias so that rename is later and safe.

`ask_genie_one` is now `ask_genie`, including in the docs page: the old name described a
backend it no longer exclusively used.

Reordering the phases so the space exists BEFORE the first deploy -- which is what makes
this default reachable without a redeploy -- is the next commit.
…sing reader

firefly_store_input chained its dedupe off grep's exit status:

    [ -f "$file" ] && grep -v "^${key}=" "$file" > "$file.tmp" && mv "$file.tmp" "$file"

`grep -v` exits 1 when it filters out EVERY line, which is precisely the case where the
file holds only this key. The chained `mv` then never ran, the original survived, and the
append added a second copy -- so re-storing an answer duplicated it and inputs.env grew on
every re-run. Readers that take the last match hid it; one taking the first would have
returned a stale answer.

Also adds firefly_read_input, which was simply missing: the writer had no counterpart, so
init_state_dir inlined its own sed to recover REPO_DIR. A phase that persists an answer and
a later phase that needs it back should not each invent the parsing -- and the reordering
needs to read GENIE_MCP_MODE back in Phase 4.
…ot after

The app was deployed workspace-wide because no space existed yet, then Phase 6c created one
and redeployed to correct it. Any failure between those two points left the app answering
from workspace-wide Genie -- which guest users cannot query at all, having no workspace
access -- and nothing said so, because the deploy really had passed the variable it was
given. `one` was the default largely because it was the only thing reachable at deploy time.

Nothing in space creation needed the app. Creating a space needs a warehouse and tables;
only GRANTING a service principal CAN_RUN on it needs the app to exist. So the two halves
split cleanly:

  Phase 3f (new)  resolve the warehouse, seed, create/resolve the space
  Phase 4         deploy ONCE, already carrying genie_mcp_mode + genie_space_id
  Phase 6c        grants only -- no redeploy, because there is nothing to correct

The warehouse moves to 3f as well, since seeding and space creation both need it and both
now precede the deploy; Phase 6 recovers it from inputs.env rather than resolving it twice.

Invariant 32 asserted that Phase 6c's redeploy waited for Phase 4's deployment to settle.
That race no longer exists, and worse, the check had begun passing VACUOUSLY: it searched
for the literal `genie_mcp_mode=space`, which is now a variable. It is replaced by one
asserting the first deploy carries both Genie vars and that no bundle deploy appears at or
after Phase 6.

Two other checks needed correcting because they described the old shape rather than the
requirement. Invariant 36 accepted only `= "space"` with an else, so the natural
`!= "space"` form read as a silent gate; it now anchors on each gate and requires every one
of them to speak, having first passed while one gate was deliberately silenced behind a
talkative one. Invariant 39 could not see that `eval "$(...)"` mints a variable, so Phase
3f's mint-then-store looked like an unguarded re-store.
…refused

Phase 3f creates the Genie space before any service principal exists, so it cannot grant and
deliberately passed no --grant-guest. A missing flag defaulted to no, so a run whose operator
had answered yes was told the guest SP would get NO access to the space -- and Phase 6c then
granted it. The flag really was absent; what the reader takes from it is that their Phase 0
answer was discarded. A trial reported it.

Deferral is now its own state: 3f passes --defer-grants and the script names where the grants
land instead of warning about access nobody withheld. The refusal warning still fires for a
real no.

Invariant 44 covers it, and found two things I had not looked at while I was writing it:

- the documented seed-recovery command creates a space and passes no --grant-guest at all,
  after the SPs exist -- so it silently dropped guest access for anyone following it. Fixed
  to carry the answer.
- my first version of the guard ranged /Phase 3f/,/Phase 4/, and 'Phase 4' appears in prose
  350 lines earlier, so the window was three lines long and it failed a file that did carry
  the flag. Re-anchored on the invocation. The rule is now that a creating call must be
  explicit either way; silence is what is banned.

Both halves verified by sabotage. The first sabotage attempt was a silent no-op and 'passed'
for that reason, which is the same failure mode as the guard above.
Two more instances of the defect fixed in c4be845, both reported by the second pass of the
same trial:

- The grants-only call passed --seed no, so a run whose operator answered yes -- and where
  Phase 3f had already seeded 16 tables -- was told seeding was 'declined at Phase 0'. --seed
  now takes skip, meaning not this call's concern, and only a real no is reported as one.
- Every message hardcoded 'Phase 6c', so a reader in Phase 3f got 'Phase 6c using warehouse
  ...' and went looking for a phase they had not reached. The caller passes --phase.

SEED_STATUS=not-this-phase is documented next to the values it could be confused with.

Both fixes verified by extracting the real branch and running it under bash and zsh. The two
new invariant clauses were first written as a grep over the whole file and both were vacuous
-- the dry-run banner mentions --seed skip, and PHASE_LABEL appears in its own comment -- so
sabotaging either changed nothing and the guard still passed. They are now scoped to the
invocation and to the message, and each has been sabotaged and seen to fail.
firefly_restore_phase6_context printed 'GENIE_SPACE_ID is set but GENIE_MCP_MODE was not -
assuming space' on a run where Phase 3f had already written the mode down. store_secret writes
state.env and firefly_store_input writes inputs.env; the restore read only state.env, so the
mode was never found. The message was accurate about what the function could see and wrong
about what was known, which is the shape of defect this runbook keeps producing.

It now reads both stores, and when the value really is in neither it says so by naming them
instead of announcing an assumption. Verified under bash and zsh for the found case, the
genuinely-absent case, and that both values reach the caller when not piped.

Invariant 45 covers it. Its first version matched the comment that explains the old message
and failed the fixed code, and the block had been inserted twice so it reported the same thing
in both directions at once. Comments are stripped, the range is bounded to the first
definition, and the duplicate is gone.
npm install -g vercel prints nothing for about 40 seconds. Phase 3a carries a warning for
exactly this shape of problem -- a harness that backgrounds a command after ~30s returns its own
exit 0 while the command is still working, so a slow silent step is indistinguishable from a
hang -- and Phase 1d had no equivalent. A run reported it.
…able table

grant_table_select sent every GRANT to /dev/null and counted the ones that returned 0, so a run
that seeded 16 tables printed 'granted SELECT on 15 table(s)' and left the reader to notice the
difference and explain it. A table the guest cannot read looked exactly like one it can.

Failures are now counted and named with the error that caused them, and the space's coverage is
compared against the schema so a seeded-but-uncovered table is stated outright -- Genie cannot
query such a table, which is a real question worth asking and impossible to ask if nobody says
which table is absent.

I am not claiming to know why the 16th table was outside the space on that run; there is no
evidence here for that, and this makes the next run say so instead of printing a number. Both
paths verified under bash and zsh with stubs that fail the way a permission error does.

Invariant 46 covers it, verified by restoring the silent version.
…will beat

The note added in ae7ac7f said the install is silent for ~40s. The next run took ~2 minutes and
reported it as a gap: a 180s wait had backgrounded the install mid-command, and the reassurance
had become the misleading thing. Quoting a duration invites planning around it.

It now says the duration is unpredictable, gives both observed figures as evidence that no
single number is safe, and gives the actionable rule instead: wait on the process rather than a
clock, and do not run a second global install alongside it.
…ng host

After vercel deploy --prod the CLI prints a per-deployment host labelled Production and offers
'Promote to production' as the next step. Both read as 'not live yet at the origin you want',
when the serving origin was already correct and Phase 8a-2 had read it from the domains API. A
run reported being sent toward a second unnecessary deploy.

The text belongs to Vercel, so the fix is to say so where the command is run rather than to
pretend the output will change.
Both the README and the user-facing docs page still described the behaviour we inverted: that
GENIE_MCP_MODE defaults to 'one' and the agent answers from workspace-wide Genie. It defaults to
'space' and answers from a curated Genie space. Anyone following either document would have
configured the opposite of what ships.

The docs page was worse than the README. It called Genie Agent 'the workspace-wide unified
Genie' -- the exact conflation this branch exists to remove -- and stated the agent 'is not
scoped to a single Genie space'. It also advertised a 'Powered by Genie / Genie Agent' link that
was deleted along with the attribution.

Only 'space' is surfaced now. Workspace-wide remains supported in code but is no longer offered
as a choice, since it cannot serve guests: a space is the only object a guest SP can be granted
CAN_RUN on. GENIE_MCP_URL is undocumented for the same reason -- it only takes effect in the mode
we no longer surface.

Also corrected, all verified against source rather than assumed:

- ask_genie_one -> ask_genie (renamed with the shared resolver)
- genie_space_id default is 'none', not empty; empty makes the Apps API reject the deploy
- the space is created in Phase 3f before the deploy, not in Phase 6c afterwards
- the 'hardcodes the MCP path / feeds the attribution-link fallback' note was wrong in both
  halves; the path is resolved centrally and the link is gone
- 'this repo ships no dataset' -- Phase 3f seeds samples.wanderbricks by default
- 'all nine phases' -> Phases 0-9 with sub-phases 3f, 6b, 6c
- the UNVERIFIED grants comment narrowed to what runs actually proved: the listed privileges are
  sufficient; the minimal set is still unknown because nobody has removed them one at a time
- unpinned 'pnpm install -g vercel' -> pinned npm, matching Phase 1d
- duplicate '### 6.' headings renumbered

Typecheck clean, all 46 invariants hold, and the page was rendered locally to confirm.
A real bootstrap run against a real workspace found what nine consecutive green end-to-end runs
did not: space mode was broken, and space is the default.

  /api/2.0/mcp/genie              genie_ask, genie_poll_response
  /api/2.0/mcp/genie/<space_id>   query_space_<id>, poll_response_<id>

87ba962 moved the PATH into one resolver and left the tool names hardcoded to the workspace-wide
pair one layer down, so the deployed agent POSTed genie_ask to a space endpoint and got
'-32602 BAD_REQUEST: Tool genie_ask does not exist'. Fixing the path while leaving the names was
the same defect with a smaller blast radius.

The argument names, id casing, status vocabulary and answer location differ too: space mode takes
 and , returns camelCase ids and uppercase states, and puts the answer in
 rather than . genie_mcp.genie_tool_names() resolves the pair and
genie_tools normalises both shapes to one.

Second defect, same run: a JSON-RPC error returned {} and discarded the message, so a precise
server explanation reached the user as 'Genie ask failed: {}'. That is the discarded-stderr
antipattern invariant 27 forbids in the runbook, in the agent this time. Errors now propagate.

Third: new-guest-link.sh could not find DATABRICKS_HOST -- the host is a Phase 0 answer in
inputs.env, not a secret in state.env -- and defaulting the workspace URL to the app origin made
the app POST client_credentials to <app-origin>/oidc/v1/token and parse its own 404 page, which
surfaced as 'Failed to obtain Databricks OAuth token'.

Fixes authored during that run; I reviewed each against the source and adopted them.

Tests, because the gap was that nothing exercised this layer:

- agent/tests/test_genie_backends.py, 14 cases over tool names, argument names, error
  propagation and both response shapes. Restoring the hardcoded names fails 5; restoring
   fails 2. The OpenAI Agents SDK is stubbed so they run on a clean checkout.
- invariant 47: names resolved per backend, and the JSON-RPC error reaches the caller. Its
  error-propagation clause first grepped the whole file, was satisfied by an unrelated
  , and passed the original bug; it is now scoped to the branch.
- make check runs the unit tests alongside the invariants.
…g the resource

Three runs against one workspace took the Genie space count from 2 to 3. The lookup that decides
reuse-or-create had three compounding defects:

1. It listed spaces with stderr sent to /dev/null, through a parser returning {} on any failure,
   so "the API did not answer" and "no such space exists" were the same value -- and the branch
   on that value CREATES. One hiccup produced a second space, with the evidence destroyed by the
   same line that caused it. Reuse-or-create has three outcomes, not two.
2. It took the first title match and broke, so once two spaces shared a title, which one got
   reused was whatever order the API returned, and duplicates were undetectable. That is why a
   later run reused an older space rather than the one created 30 minutes earlier.
3. It read one page and never followed next_page_token, so correctness depended on a space count
   nobody was watching.

Now: the call is checked and an unknown result REFUSES to create while naming the error, every
page is read, all matches are counted, and duplicates are reported with a deterministic choice
(lowest id) so repeated runs agree.

Verified with a stubbed API across four paths: single match reuses; two matches warn and pick
deterministically; no match still creates; a failed list refuses and says why.

Invariant 48 covers it, verified by restoring the original lookup verbatim -- four of its five
clauses fire.

What I have NOT established is which of the three defects produced the specific duplicate I was
asked about. ours_count stayed 1 across all three runs while the total rose, and with the same
catalog.schema on both runs the titles should have matched, so either the title differed or the
count was wrong. Nothing recorded enough to say which, because the line that would have shown it
is the one that discarded the error. The harness now records every space title, so the next
occurrence is answerable rather than arguable.
This is the answer to why more than one Genie space gets created. The lookup printed
"Genie spaces visible: 0" on a workspace holding four spaces, and created another. An empty list
that returns exit 0 is indistinguishable from a genuinely empty workspace, so the exit-status
check added in daf2bbb does not catch it -- and the fallback on "no spaces exist" is to create.

Bootstrap already knows better: it wrote the space id down. If the list cannot see a space we
recorded, and that space is still readable, the LIST is wrong and the record is right. It now
reuses the recorded space and says the list was inconsistent, rather than creating a second one.
When the recorded space is genuinely gone, creating a replacement is correct and it says that too.

Verified with stubs across four paths: empty list with a readable recorded space reuses it; empty
list with a deleted one creates; empty list with nothing recorded creates; the "none" sentinel is
not treated as an id.

Two guesses I made along the way were wrong, which is worth recording because both sounded
plausible and neither survived contact with the evidence. First, that the runs were creating
duplicates of the same title -- ours_count stayed at 1 throughout, so they were not exact
duplicates. Second, that a transient list FAILURE caused it -- the list did not fail, it returned
success with nothing in it. The full titles and the visible-count that the harness now records are
what settled it.
firefly_install_databricks_cli resolved "latest" through an unauthenticated call to
api.github.com, which allows 60 requests per hour per IP. A machine running the runbook
repeatedly exhausts that, and the whole run then dies: Phases 3a-6 and 8b-9 all fail without the
CLI, with no recovery path. That is what happened on the run meant to validate the Genie space
fix -- it never reached Phase 3f, so the validation learned nothing.

The message made it worse. GitHub answers 403 with a body saying "API rate limit exceeded", and
the installer reported "could not resolve the latest Databricks CLI release", which sends the
reader to networking or to the Databricks release page. Neither is the problem.

Three changes:

- Authenticate when a token exists: GH_TOKEN, GITHUB_TOKEN, or `gh auth token`. That raises the
  limit from 60/hour to 5000/hour, and gh is already installed by Phase 1e.
- When the API still cannot answer, install FIREFLY_DATABRICKS_CLI_FALLBACK (1.9.0) rather than
  failing. The download host is not API-rate-limited, so the fallback works precisely when the
  lookup does not. It is a floor, not a preference -- every phase asserts the CLI works, not its
  version.
- Say what a 403 here usually means, and only suggest a token when none was supplied.

Verified across three paths with a stubbed curl: a normal answer resolves the real tag; a 403
without a token falls back and names the rate limit; a 403 with a token falls back without
offering token advice that would not help.
…never matched our own spaces

This is the actual cause of the accumulating Genie spaces, and it is not what my two previous
commits said.

The workspace holds these titles:

    Firefly Genie Agent — <cat>.default 2026-07-29 18:15:23
    Firefly Genie Agent — <cat>.default 2026-07-29 17:30:18
    Firefly Genie Agent — <cat>.default 2026-07-29 17:02:24
    Firefly Genie Agent — <cat>.default 2026-07-29 15:44:23
    Firefly Genie Agent — <cat>.default

space_title_for adds no timestamp -- it is `printf 'Firefly Genie Agent — %s.%s'`. The server
appends one on creation. So the reuse lookup, which tested title EQUALITY, could never match a
space this script had created: only the one bare-titled space matched, every other run concluded
no space existed, and each created another. That is also why ours_count sat at 1 across every run
while the total climbed to six.

The matcher now accepts our title followed by a timestamp, and only a timestamp -- a bare prefix
test would also swallow a different schema whose name extends ours (default vs default_other),
which is checked.

Two earlier explanations were wrong and are worth naming, because each was plausible and each sent
me somewhere useless:

- daf2bbb: a transient list FAILURE. The list did not fail.
- ffee3de: an empty-but-successful list. Real, and the cross-check it added is worth keeping, but
  it was not the cause -- and it could not have been the whole story, because bootstrap runs on a
  fresh VM where state.env holds no prior space id to cross-check against.

Still unexplained: the product's own list printed "Genie spaces visible: 0" on the same run where
the harness listed six spaces on that workspace. Same profile, minutes apart. The prefix fix makes
reuse work whenever the list is populated; it does nothing when the list comes back empty, and I do
not yet know why it does.
"Genie spaces visible: 0" on a workspace holding several spaces was my own bug, introduced in
daf2bbb when I added pagination. That version appends each page to a file and parses it LINE BY
LINE, but `databricks api get` pretty-prints, so every line is a fragment, every json.loads fails,
and every page counts zero. The version before it piped the whole response into json.load, which
does not care about newlines.

Each page is now compacted to a single line before it is appended.

Verified side by side against a pretty-printed response: the fixed reader sees both spaces and
reuses the lower id; the line-by-line parse sees zero.

This invalidates two explanations I committed with confidence:

- ffee3de blamed an empty-but-successful list and added a cross-check against the recorded space
  id. The list was never empty. The cross-check is still correct and worth keeping -- it just
  never fired, because on a fresh VM state.env holds no prior id.
- 1092c5e blamed the server-appended title timestamp. That one is real and confirmed from the
  recorded titles, and equality genuinely could not match our own spaces. But it was not what the
  runs were hitting after daf2bbb, because the reader was returning zero spaces before the matcher
  ever ran.

Both fixes stay. The order they were found in is the reverse of the order they mattered, and the
reason I chased two wrong causes first is that the diagnostic I added to explain the behaviour --
the visible-count -- was itself reporting the bug I had just written.
… disabled

Raised by a reviewer, and confirmed: the MCP tool-denial short-circuit in chat.ts sat inside
`if (dbAvailable && requestBody.previousMessages)`, so with no database a continuation carrying a
denial skipped the check and fell through to streamText. The model was called against a tool the
user had just refused.

Ephemeral mode is not a corner: the handler documents it ("Works in ephemeral mode when database is
disabled") and logs it on entry, so this is a supported configuration in which a user denial did
nothing.

The check was nested one level deeper than the report described -- also inside
`assistantMessages.length > 0` -- while hasMcpDenial scans ALL previous messages, not just
assistant ones. So a denial was also skipped when a continuation carried no assistant messages at
all, regardless of the database.

Both guards now cover only saveMessages, which is the sole part that needs a database. Persistence
and permission are unrelated concerns.

Verified: the patched file produces the same ten TS2307 module-resolution errors as HEAD and no
syntax or type faults (the vendored app is not checked out locally, so those are expected and
unchanged). Invariant 49 asserts the denial check is not inside the dbAvailable block and that it
exists at all, verified by restoring the original nesting -- the reviewer's exact case -- and by
deleting the check outright.
…sation

Found by a cache-cleared isaac review of genie-agent against main.

hasMcpDenial scanned the entire previousMessages array with .some(), matching any denied tool part
anywhere in history. A denied part stays in the client's message history forever, so after a single
denial every subsequent continuation matched it and returned res.end() -- approving a later tool
silently dropped the continuation and the conversation stalled with no way forward.

"Any denial anywhere" was never the question. "Was the interaction we are continuing FROM denied"
is, so the check now looks at the most recent dynamic-tool part only.

The scan itself predates 33fc179; that commit hoisted the block out of the dbAvailable guard, which
was correct on its own terms and also widened where this fired -- it now applied in ephemeral mode
too. Fixing the nesting without reading the predicate was an incomplete job.

Verified against six cases, including the one that matters: a denial followed by an approval now
proceeds where the old logic stopped. Invariant 49 gained two clauses -- the check must not be a
blanket .some() over history, and something must identify the most recent tool part -- both proven
by restoring the old shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant